#!/usr/bin/python3
# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: t -*-

import os
import re
import glob
import json
import lxml.etree
import logging
from datetime import timedelta
from mc_util import McUtil
from mc_param import McConst


class McGlobalDataManager:

    def __init__(self, param):
        self.param = param

    def dispose(self):
        for obj in self.param.advertiserDict.values():
            obj.dispose()
        for obj in self.param.storageDict.values():
            obj.dispose()
        self.param.mirrorSiteDict.clear()
        self.param.mirrorSiteFactoryDict.clear()
        self.param.advertiserDict.clear()
        self.param.storageDict.clear()

    def getEnabledPluginNameList(self):
        ret = []
        for fn in glob.glob(McConst.pluginCfgFileGlobPattern):
            pluginName = McUtil.rreplace(os.path.basename(fn).replace("plugin-", "", 1), ".conf", "", 1)
            pluginPath = os.path.join(McConst.pluginsDir, pluginName)
            if not os.path.isdir(pluginPath):
                continue
            ret.append(pluginName)
        return ret

    def getStorageNameList(self):
        ret = os.listdir(McConst.storageDir)
        ret = [x for x in ret if os.path.isdir(os.path.join(McConst.storageDir, x))]
        return ret

    def getAdvertiserNameList(self):
        return os.listdir(McConst.advertiserDir)

    def load(self):
        for pluginName in self.getEnabledPluginNameList():
            with open(os.path.join(McConst.etcDir, "plugin-%s.conf" % (pluginName)), "r") as f:
                buf = f.read()
                if buf != "":
                    pluginCfg = json.loads(buf)
                else:
                    pluginCfg = dict()
            self._loadPlugin(pluginName, os.path.join(McConst.pluginsDir, pluginName), pluginCfg)

        # show log message in a controlled manner
        for factoryId in sorted(list(self.param.mirrorSiteFactoryDict)):
            logging.info("Mirror site factory \"%s\" added." % (factoryId))
        for msId in sorted(list(self.param.mirrorSiteDict)):
            logging.info("Mirror site \"%s\" added." % (msId))

    def addMirrorSite(self, pluginDir, rootElem, cfgDict, factoryId=None):
        msId = self._addMirrorSite(pluginDir, rootElem, cfgDict, factoryId)
        logging.info("Mirror site \"%s\" added." % (msId))
        return msId

    def removeMirrorSite(self, msId):
        self._removeMirrorSite(msId)
        logging.info("Mirror site \"%s\" removed." % (msId))

    def _addMirrorSite(self, pluginDir, rootElem, cfgDict, factoryId=None):
        msId = rootElem.get("id")
        if msId in self.param.mirrorSiteDict:
            raise DuplicateMirrorSiteError(msId)
        if msId in self.param.mirrorSiteFactoryDict:
            raise DuplicateMirrorSiteError(msId)

        # create mirror site object
        msObj = _McMirrorSite(self.param, pluginDir, msId, rootElem, cfgDict, factoryId)

        # load storage objects
        for name in msObj.storageDict:
            self._loadStorageObject(name)
            self.param.storageDict[name].add_mirror_site(msId, msObj.getPropertiesForStorage(name))

        # load advertiser objects
        for name in msObj.advertiserDict:
            self._loadAdvertiserObject(name)
            self.param.advertiserDict[name].add_mirror_site(msId, msObj.getPropertiesForAdvertiser(name))

        self.param.mirrorSiteDict[msId] = msObj
        return msId

    def _removeMirrorSite(self, msId):
        del self.param.mirrorSiteDict[msId]

    def _addMirrorSiteFactory(self, pluginDir, rootElem, cfgDict):
        factoryId = rootElem.get("id")
        if factoryId in self.param.mirrorSiteDict:
            raise DuplicateMirrorSiteFactoryError(factoryId)
        if factoryId in self.param.mirrorSiteFactoryDict:
            raise DuplicateMirrorSiteFactoryError(factoryId)

        obj = _McMirrorSiteFactory(self.param, pluginDir, factoryId, rootElem, cfgDict)
        self.param.mirrorSiteFactoryDict[factoryId] = obj
        return factoryId

    def _removeMirrorSiteFactory(self, factoryId):
        del self.param.mirrorSiteFactoryDict[factoryId]

    def _loadStorageObject(self, name):
        if name in self.param.storageDict:
            return

        mod = getattr(__import__("storage.%s" % (name)), name)

        # prepare storage initialization parameter
        param = {
            "log-directory": os.path.join(McConst.logDir, "storage-%s" % (name)),
        }
        if mod.Storage.get_properties().get("need-temp-directory", False):
            param.update({
                "temp-directory": os.path.join(McConst.tmpDir, "storage-%s" % (name)),
            })
        if mod.Storage.get_properties().get("with-integrated-advertiser", False):
            param.update({
                "listen-ip": self.param.listenIp,
            })

        # prepare directories
        if "temp-directory" in param:
            os.makedirs(param["temp-directory"])
        os.makedirs(param["log-directory"], exist_ok=True)

        # create object
        self.param.storageDict[name] = mod.Storage(param)

    def _loadAdvertiserObject(self, name):
        if name in self.param.advertiserDict:
            return

        mod = getattr(__import__("advertiser.%s" % (name)), name)
        param = {}

        # log directory
        param["log-directory"] = os.path.join(McConst.logDir, "advertiser-%s" % (name))
        os.makedirs(param["log-directory"], exist_ok=True)

        # temp directory
        if mod.Advertiser.get_properties().get("need-temp-directory", False):
            param["temp-directory"] = os.path.join(McConst.tmpDir, "advertiser-%s" % (name))
            os.makedirs(param["temp-directory"])

        # listen ip
        param["listen-ip"] = self.param.listenIp

        # create object
        self.param.advertiserDict[name] = mod.Advertiser(param)

    def _loadPlugin(self, name, path, pluginCfg):
        # get metadata.xml file
        metadata_file = os.path.join(path, "metadata.xml")
        if not os.path.exists(metadata_file):
            raise MetadataXmlError("plugin %s has no metadata.xml" % (name))
        if not os.path.isfile(metadata_file):
            raise MetadataXmlError("metadata.xml for plugin %s is not a file" % (name))
        if not os.access(metadata_file, os.R_OK):
            raise MetadataXmlError("metadata.xml for plugin %s is invalid" % (name))

        # check metadata.xml file content
        # FIXME
        rootElem = lxml.etree.parse(metadata_file).getroot()
        # if True:
        #     dtd = libxml2.parseDTD(None, constants.PATH_PLUGIN_DTD_FILE)
        #     ctxt = libxml2.newValidCtxt()
        #     messages = []
        #     ctxt.setValidityErrorHandler(lambda item, msgs: msgs.append(item), None, messages)
        #     if tree.validateDtd(ctxt, dtd) != 1:
        #         msg = ""
        #         for i in messages:
        #             msg += i
        #         raise exceptions.IncorrectPluginMetaFile(metadata_file, msg)

        # create _McMirrorSite objects
        if rootElem.tag == "mirror-site":
            siteCfg = pluginCfg
            self._addMirrorSite(path, rootElem, siteCfg)
        elif rootElem.tag == "mirror-site-factory":
            factoryCfg = pluginCfg
            self._addMirrorSiteFactory(path, rootElem, factoryCfg)
        elif rootElem.tag == "mirror-sites":
            bEnableAtLeastOne = False
            for child in rootElem.xpath("./mirror-site"):
                msId = child.get("id")
                siteCfg = pluginCfg.get(msId, {})
                if siteCfg.get("disabled", False):
                    continue
                self._addMirrorSite(path, child, siteCfg)
                bEnableAtLeastOne = True
            for child in rootElem.xpath("./mirror-site-factory"):
                factoryId = child.get("id")
                factoryCfg = pluginCfg.get(factoryId, {})
                if factoryCfg.get("disabled", False):
                    continue
                self._addMirrorSiteFactory(path, child, factoryCfg)
                bEnableAtLeastOne = True
            if not bEnableAtLeastOne:
                raise NoMirrorSiteOrMirrorSiteFactoryEnabledError("plugin %s has no mirror site or mirror site factory enabled" % (name))
        else:
            raise MetadataXmlError("metadata.xml content for plugin %s is invalid" % (name))


class MetadataXmlError(Exception):
    pass


class NoMirrorSiteOrMirrorSiteFactoryEnabledError(Exception):
    pass


class DuplicateMirrorSiteError(Exception):
    pass


class DuplicateMirrorSiteFactoryError(Exception):
    pass


class _McMirrorSite:

    def __init__(self, param, pluginDir, msId, rootElem, cfgDict, factoryId=None):
        self.param = param
        self.id = msId
        self.cfgDict = cfgDict
        self.factoryId = factoryId

        # persist mode
        self.bPersist = cfgDict.get("persist", False)

        # master directory
        if self.bPersist:
            self.masterDir = os.path.join(McConst.varDir, self.id)
        else:
            self.masterDir = os.path.join(McConst.cacheDir, self.id)
        os.makedirs(self.masterDir, exist_ok=True)

        # state directory (for plugin initializer/updater/maintainer)
        self.pluginStateDir = os.path.join(self.masterDir, "state")
        os.makedirs(self.pluginStateDir, exist_ok=True)

        # log directory (for plugin initializer/updater/maintainer)
        self.logDir = os.path.join(self.masterDir, "log")
        self.mainUpdaterLogFile = os.path.join(self.logDir, "updater.log")              # log file for initializer/updater/maintainer stdout/stderr

        # storage
        self.storageDict = dict()                       # {name:[config-xml,data-directory]}
        for child in rootElem.xpath("./storage"):
            st = child.get("type")
            if st not in self.param.globalDataManager.getStorageNameList():
                raise Exception("mirror site %s: invalid storage type %s" % (self.id, st))
            # record outer xml
            self.storageDict[st] = [lxml.etree.tostring(child, encoding="unicode"), os.path.join(self.masterDir, "storage-%s" % (st))]
            # create data directory
            os.makedirs(self.storageDict[st][1], exist_ok=True)

        # advertiser
        self.advertiserDict = dict()                 # {name:[config-xml]}
        for child in rootElem.xpath("./advertiser"):
            st = child.get("type")
            if st not in self.param.globalDataManager.getAdvertiserNameList():
                raise Exception("mirror site %s: invalid advertiser type %s" % (self.id, st))
            # record outer xml
            self.advertiserDict[st] = [lxml.etree.tostring(child, encoding="unicode")]

        # initializer
        self.initializerExe = None
        self.initializerTmpDir = None
        if True:
            slist = rootElem.xpath("./initializer")
            if len(slist) > 0:
                self.initializerExe = slist[0].xpath("./executable")[0].text
                self.initializerExe = os.path.join(pluginDir, self.initializerExe)

                if len(slist[0].xpath("./need-temp-directory")) > 0:
                    self.initializerTmpDir = os.path.join(McConst.tmpDir, "mirror-site-%s" % (self.id))

        # updater
        self.updaterExe = None
        self.updaterTmpDir = None
        self.schedType = None              # "interval" or "cronexpr"
        self.schedInterval = None          # timedelta
        self.schedCronExpr = None          # string
        self.updateRetryType = None        # "interval" or "cronexpr"
        self.updateRetryInterval = None    # timedelta
        self.updateRetryCronExpr = None    # string
        if True:
            slist = rootElem.xpath("./updater")
            if len(slist) > 0:
                self.updaterExe = slist[0].xpath("./executable")[0].text
                self.updaterExe = os.path.join(pluginDir, self.updaterExe)

                if len(slist[0].xpath("./need-temp-directory")) > 0:
                    self.initializerTmpDir = os.path.join(McConst.tmpDir, "mirror-site-%s" % (self.id))

                tag = slist[0].xpath("./schedule")[0]
                self.schedType = tag.get("type")
                if self.schedType == "interval":
                    self.schedInterval = self._parseInterval(tag.text)
                elif self.schedType == "cronexpr":
                    self.schedCronExpr = self._parseCronExpr(tag.text)
                else:
                    raise Exception("mirror site %s: invalid schedule type %s" % (self.id, self.schedType))

                if len(slist[0].xpath("./retry-after-failure")) > 0:
                    tag = slist[0].xpath("./retry-after-failure")[0]
                    self.updateRetryType = tag.get("type")
                    if self.updateRetryType == "interval":
                        self.updateRetryInterval = self._parseInterval(tag.text)
                    elif self.updateRetryType == "cronexpr":
                        if self.schedType == "interval":
                            raise Exception("mirror site %s: invalid retry-after-update type %s" % (self.id, self.updateRetryType))
                        self.updateRetryCronExpr = self._parseCronExpr(tag.text)
                    else:
                        raise Exception("mirror site %s: invalid retry-after-update type %s" % (self.id, self.updateRetryType))

        # maintainer
        self.maintainerExe = None
        self.maintainerTmpDir = None
        if True:
            slist = rootElem.xpath("./maintainer")
            if len(slist) > 0:
                self.maintainerExe = slist[0].xpath(".//executable")[0].text
                self.maintainerExe = os.path.join(pluginDir, self.maintainerExe)

                if len(slist[0].xpath("./need-temp-directory")) > 0:
                    self.maintainerTmpDir = os.path.join(McConst.tmpDir, "mirror-site-%s" % (self.id))

    def getPropertiesForStorage(self, storageName):
        return {
            "config-xml": self.storageDict[storageName][0],
            "state-directory": self.pluginStateDir,
            "data-directory": self.storageDict[storageName][1],
        }

    def getPropertiesForAdvertiser(self, advertiserName):
        prop = {
            "config-xml": self.advertiserDict[advertiserName][0],
            "state-directory": self.pluginStateDir,
            "storage-param": dict(),
        }
        for st in self.storageDict:
            if st in self.param.advertiserDict[advertiserName].get_properties().get("storage-dependencies", []):
                prop["storage-param"][st] = self.param.storageDict[st].get_param_for_advertiser(self.id)
        return prop

    def _parseInterval(self, intervalStr):
        m = re.match("([0-9]+)(h|d|w)", intervalStr)
        if m is None:
            raise Exception("invalid interval %s" % (intervalStr))

        if m.group(2) == "h":
            return timedelta(hours=int(m.group(1)))
        elif m.group(2) == "d":
            return timedelta(days=int(m.group(1)))
        elif m.group(2) == "w":
            return timedelta(weeks=int(m.group(1)))
        else:
            assert False

    def _parseCronExpr(self, cronExprStr):
        # FIXME: should add checking
        return cronExprStr


class _McMirrorSiteFactory:

    def __init__(self, param, pluginDir, factoryId, rootElem, cfgDict):
        self.param = param
        self.pluginDir = pluginDir
        self.id = factoryId
        self.cfgDict = cfgDict

        # log directory
        self.logDir = os.path.join(McConst.logDir, "msfactory-%s" % (self.id))
        self.mainLogFile = os.path.join(self.logDir, "main.log")              # log file for runner stdout/stderr
        os.makedirs(self.logDir, exist_ok=True)

        # get runner
        self.runnerExe = None
        if True:
            slist = rootElem.xpath("./executable")
            if len(slist) == 0:
                raise Exception("mirror site factory %s: no executable specified" % (self.id))
            self.runnerExe = slist[0].text
            self.runnerExe = os.path.join(pluginDir, self.runnerExe)
